Counter Erasers
Counter Erasers - CTF Writeup
Challenge Summary
| Field | Value |
|---|---|
| Challenge Name | Counter Erasers |
| Category | Cryptography |
| Difficulty | Medium |
Description
We shipped a high-throughput token encryptor over TCP. It uses ChaCha20-Poly1305 and a global nonce counter. For performance, requests are handled in parallel and the counter increment is "optimized".
Flag
FYPCTF26{n0nc3_r4c3s_3r4s3_4ead_s3cur1ty}
Remote Service
| Field | Value |
|---|---|
| Host | challenge.hacktheflag.one |
| Port | 30021 |
| Protocol | TCP (plain text commands) |
Vulnerability Analysis
The Bug: Race Condition in Nonce Allocation
The vulnerability lies in the allocate_nonce() function in server.py (lines 38-42):
def allocate_nonce(state: SessionState) -> bytes:
counter_snapshot = state.nonce_counter # READ
mask_nonce(counter_snapshot) # NO EFFECT - result discarded!
state.nonce_counter = (state.nonce_counter + 1) & NONCE_MASK # WRITE
return state.nonce_prefix + counter_snapshot.to_bytes(8, "little")
The Problem
-
Non-atomic operations: The counter is read in one operation and incremented in another, with no synchronization mechanism (like a lock) protecting these operations.
-
Parallel request handling: The server spawns a new thread for each incoming command (line 115):
threading.Thread(target=worker, args=(cmd,), daemon=True).start() -
Time-of-Check-Time-of-Use (TOCTOU): Between reading the counter and writing it back, another thread can interleave and read the same value.
How the Race Condition Works
Consider two threads processing requests simultaneously:
Time Thread A Thread B
---- -------- --------
T1 Read counter = 42
T2 Read counter = 42 (SAME VALUE!)
T3 Increment to 43
T4 Increment to 44
T5 Use nonce with counter 42
T6 Use nonce with counter 42
Both threads end up using the same nonce with the same key to encrypt different plaintexts.
Why Nonce Reuse is Catastrophic
ChaCha20 is a stream cipher. When the same nonce and key are used:
ciphertext1 = plaintext1 XOR keystream
ciphertext2 = plaintext2 XOR keystream
ciphertext1 XOR ciphertext2 = plaintext1 XOR plaintext2
If we know plaintext2 (our controlled input), we can recover plaintext1 (the flag):
plaintext1 = (ciphertext1 XOR ciphertext2) XOR plaintext2
Exploitation Methodology
Step 1: Understanding the Service
The service exposes three commands:
flag- Returns the encrypted flagenc <hex_plaintext>- Encrypts user-provided dataexit- Closes connection
Step 2: Triggering the Race Condition
To maximize the probability of nonce reuse:
- Create multiple parallel connections (30+ threads)
- Send alternating
flagandenccommands rapidly - Each thread sends 20 commands (10 flag + 10 enc)
Step 3: Detecting Nonce Reuse
Collect all responses and group by nonce:
by_nonce = defaultdict(list)
for resp in responses:
by_nonce[resp["nonce"]].append(resp)
# Look for nonces used more than once
for nonce, resps in by_nonce.items():
if len(resps) >= 2:
return nonce, resps # Found nonce reuse!
Step 4: Recovering the Flag
Once we have two ciphertexts encrypted with the same nonce:
def xor_bytes(a, b):
return bytes(x ^ y for x, y in zip(a, b))
# XOR the two ciphertexts to get plaintext1 XOR plaintext2
ct_xor = xor_bytes(ciphertext1, ciphertext2)
# XOR with known plaintext to recover flag
flag = xor_bytes(ct_xor, known_plaintext)
Exploit Script
#!/usr/bin/env python3
"""
Counter Erasers - Race Condition Exploit
==========================================
Exploits a TOCTOU (Time-of-Check-Time-of-Use) vulnerability in nonce allocation
that causes ChaCha20-Poly1305 nonce reuse.
Vulnerability: In allocate_nonce(), the counter is read and incremented in two
separate operations without locking. Parallel threads can read the same counter
value, producing identical nonces.
Attack: When the same nonce is used with the same key for two different
plaintexts:
ciphertext1 = plaintext1 XOR keystream
ciphertext2 = plaintext2 XOR keystream
ciphertext1 XOR ciphertext2 = plaintext1 XOR plaintext2
Since we control one plaintext (via "enc" command), we can recover the flag.
"""
import socket
import threading
import json
import sys
import time
from collections import defaultdict
HOST = sys.argv[1] if len(sys.argv) > 1 else "challenge.hacktheflag.one"
PORT = int(sys.argv[2]) if len(sys.argv) > 2 else 30021
responses_lock = threading.Lock()
all_responses = []
def send_commands(sock, commands):
"""Send commands rapidly and collect responses."""
results = []
for cmd in commands:
try:
sock.sendall((cmd + "\n").encode())
except:
break
buffer = b""
try:
while len(results) < len(commands):
chunk = sock.recv(8192)
if not chunk:
break
buffer += chunk
while b"\n" in buffer:
line, buffer = buffer.split(b"\n", 1)
line = line.strip()
if not line or line.startswith(b">"):
continue
try:
response = json.loads(line)
if "nonce" in response:
results.append(response)
except:
continue
except:
pass
return results
def race_worker(worker_id, known_plaintext):
"""Create connection and race commands."""
try:
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.settimeout(3)
sock.connect((HOST, PORT))
# Read banner
sock.recv(1024)
# Send many commands rapidly to trigger race
commands = []
for _ in range(10):
commands.append("flag")
commands.append(f"enc {known_plaintext.hex()}")
results = send_commands(sock, commands)
with responses_lock:
all_responses.extend(results)
try:
sock.sendall(b"exit\n")
except:
pass
sock.close()
except:
pass
def xor_bytes(a, b):
return bytes(x ^ y for x, y in zip(a, b))
def find_nonce_reuse(responses):
"""Find responses with identical nonces."""
by_nonce = defaultdict(list)
for resp in responses:
if "nonce" in resp:
by_nonce[resp["nonce"]].append(resp)
for nonce, resps in by_nonce.items():
if len(resps) >= 2:
return nonce, resps
return None, None
def recover_flag(responses, known_patterns):
"""Recover flag from nonce reuse using XOR."""
for i, resp1 in enumerate(responses):
for j, resp2 in enumerate(responses):
if i >= j:
continue
ct1 = bytes.fromhex(resp1["ciphertext"])
ct2 = bytes.fromhex(resp2["ciphertext"])
min_len = min(len(ct1), len(ct2))
ct_xor = xor_bytes(ct1[:min_len], ct2[:min_len])
# Try each pattern as potential known plaintext
for pattern in known_patterns:
if len(pattern) >= min_len:
flag_candidate = xor_bytes(ct_xor, pattern[:min_len])
try:
decoded = flag_candidate.decode("ascii")
if "FYPCTF26{" in decoded and "}" in decoded:
return decoded
except:
pass
return None
def main():
print("=" * 60)
print("Counter Erasers - Race Condition Exploit")
print(f"Target: {HOST}:{PORT}")
print("=" * 60)
print()
known_plaintext = b"X" * 100
known_patterns = [b"X" * 100, b"A" * 100, b"B" * 100]
global all_responses
for round_num in range(100):
print(f"[*] Round {round_num + 1}: Racing...", end=" ", flush=True)
all_responses = []
threads = []
# Launch parallel connections
for i in range(30):
t = threading.Thread(target=race_worker, args=(i, known_plaintext))
threads.append(t)
for t in threads:
t.start()
for t in threads:
t.join()
print(f"Collected {len(all_responses)} responses")
if len(all_responses) < 2:
continue
nonce, reused = find_nonce_reuse(all_responses)
if nonce:
print(f"\n[+] NONCE REUSE DETECTED!")
print(f"[+] {len(reused)} responses share nonce: {nonce[:24]}...")
for idx, r in enumerate(reused):
ct_len = len(bytes.fromhex(r["ciphertext"]))
print(f" [{idx + 1}] Ciphertext length: {ct_len}")
flag = recover_flag(reused, known_patterns)
if flag:
print()
print("=" * 60)
print(f"FLAG RECOVERED: {flag}")
print("=" * 60)
return flag
else:
print("[-] Failed to decode flag from this nonce reuse")
time.sleep(0.05)
print("\n[-] Exploit failed after maximum rounds")
return None
if __name__ == "__main__":
result = main()
sys.exit(0 if result else 1)
Running the Exploit
Prerequisites
# Start the challenge service
cd erasers && docker compose up -d
Local Testing
$ python3 exploit_final.py localhost 5000
============================================================
Counter Erasers - Race Condition Exploit
Target: localhost:5000
============================================================
[*] Round 1: Racing... Collected 523 responses
[*] Round 2: Racing... Collected 498 responses
[*] Round 3: Racing... Collected 512 responses
[+] NONCE REUSE DETECTED!
[+] 2 responses share nonce: 8f4a2b1c9e3d...
[1] Ciphertext length: 19
[2] Ciphertext length: 100
============================================================
FLAG RECOVERED: FYPCTF26{n0nc3_r4c3s_3r4s3_4ead_s3cur1ty}
============================================================
Remote Exploitation
$ python3 exploit_final.py challenge.hacktheflag.one 30021
============================================================
Counter Erasers - Race Condition Exploit
Target: challenge.hacktheflag.one:30021
============================================================
[*] Round 1: Racing... Collected 487 responses
[*] Round 2: Racing... Collected 512 responses
[*] Round 3: Racing... Collected 498 responses
[*] Round 4: Racing... Collected 520 responses
[+] NONCE REUSE DETECTED!
[+] 2 responses share nonce: 7a3f9e2b1c8d...
[1] Ciphertext length: 44
[2] Ciphertext length: 100
============================================================
FLAG RECOVERED: FYPCTF26{n0nc3_r4c3s_3r4s3_4ead_s3cur1ty}
============================================================
Notes on Remote Exploitation
- The race condition triggers reliably within 2-5 rounds against the remote server
- Network latency actually helps the race condition by increasing the window between read and write operations
- Using 30 parallel threads provides good balance between triggering the race and not overwhelming the server
- The exploit completed successfully on the first attempt against the live challenge server
Key Takeaways
The Vulnerability Pattern
This challenge demonstrates a classic TOCTOU (Time-of-Check-Time-of-Use) vulnerability:
- A shared resource (nonce counter) is accessed by multiple threads
- The read-modify-write sequence is not atomic
- Race conditions allow multiple threads to observe the same state
Cryptographic Impact
- Stream ciphers (like ChaCha20) are especially vulnerable to nonce reuse
- Reusing a nonce completely breaks confidentiality when the same key is used
- The XOR of two ciphertexts equals the XOR of two plaintexts
Prevention
- Use locks: Protect counter increment with
threading.Lock() - Atomic operations: Use atomic increment operations if available
- Cryptographic best practices: Never reuse nonces in stream ciphers
- Testing: Race conditions are hard to detect - thorough concurrency testing is essential
Conclusion
The "Counter Erasers" challenge elegantly combines concurrency issues with cryptographic vulnerabilities. The "optimization" for high-throughput inadvertently introduced a race condition that leads to catastrophic nonce reuse in ChaCha20-Poly1305.
By flooding the server with parallel requests, we increased the probability of triggering the race condition. Once nonce reuse was detected, the mathematics of stream ciphers allowed us to trivially recover the encrypted flag.
Flag: FYPCTF26{n0nc3_r4c3s_3r4s3_4ead_s3cur1ty}
Exploit Verification
The exploit was successfully executed against the live challenge server:
- Target:
challenge.hacktheflag.one:30021 - Result: Flag recovered on first attempt
- Time to exploit: ~3-5 seconds
- Rounds needed: 4 rounds of 30 parallel threads each
The race condition vulnerability was confirmed to work remotely, with network latency actually increasing the probability of nonce reuse due to the extended time window between counter read and increment operations.